Skip to content

feat(security): Add rate limiting middleware to prevent API abuse#117

Closed
DevOpsMadDog wants to merge 4 commits into
mainfrom
fix/auth-rate-limiting
Closed

feat(security): Add rate limiting middleware to prevent API abuse#117
DevOpsMadDog wants to merge 4 commits into
mainfrom
fix/auth-rate-limiting

Conversation

@DevOpsMadDog
Copy link
Copy Markdown
Owner

@DevOpsMadDog DevOpsMadDog commented Oct 18, 2025

🟡 Medium Severity Fix #4: Rate Limiting

Problem

No rate limiting was implemented, making the API vulnerable to:

  • Brute force attacks on authentication
  • API abuse and DoS attacks
  • Resource exhaustion

Solution

Implemented comprehensive rate limiting middleware with:

Features

  • Per-IP tracking using sliding window algorithm
  • Configurable limits via environment variables
  • Automatic cleanup of stale request trackers
  • Proxy support via X-Forwarded-For header
  • Standard HTTP headers (X-RateLimit-*, Retry-After)
  • HTTP 429 responses when limit exceeded

Configuration

# Environment variables with defaults
FIXOPS_RATE_LIMIT_ENABLED=true
FIXOPS_RATE_LIMIT_REQUESTS=100
FIXOPS_RATE_LIMIT_WINDOW_SECONDS=60

Response Headers

All responses include:

  • X-RateLimit-Limit: Maximum requests per window
  • X-RateLimit-Remaining: Requests remaining
  • X-RateLimit-Reset: Unix timestamp when window resets
  • Retry-After: Seconds to wait (on 429 responses)

Implementation Details

  • In-memory storage with automatic cleanup every 5 minutes
  • Thread-safe using locks for concurrent requests
  • Clean separation of concerns with dedicated module
  • Comprehensive error messages with retry guidance

Changes

  • Created apps/api/rate_limiter.py with RateLimitMiddleware
  • Integrated middleware into FastAPI app
  • Added configuration via environment variables
  • Protects all API endpoints including authentication flows

Impact

  • Before: No protection against brute force or API abuse
  • After: Industry-standard rate limiting with proper HTTP headers

Testing

  • ✅ Syntax validation passed
  • ✅ Backward compatible (can be disabled via env var)
  • ✅ No breaking changes to existing API contracts

Future Enhancements

  • Redis-backed storage for distributed deployments
  • Per-endpoint rate limit customization
  • API key-based rate limit tiers

Resolves: Medium severity issue identified in code review


Summary by cubic

Add per-IP rate limiting to all API endpoints to stop brute force attacks and API abuse. Also persist the JWT secret in demo mode, fix config deep merge mutation, and validate upload chunk offset.

  • New Features

    • Sliding-window rate limiting per IP (supports X-Forwarded-For).
    • Configurable via env: FIXOPS_RATE_LIMIT_ENABLED, FIXOPS_RATE_LIMIT_REQUESTS, FIXOPS_RATE_LIMIT_WINDOW_SECONDS.
    • Returns 429 with Retry-After; adds X-RateLimit-* headers.
    • Automatic cleanup of stale trackers; thread-safe.
    • Protects authentication endpoints by default.
  • Bug Fixes

    • Persist JWT secret to FIXOPS_DATA_DIR/.jwt_secret with 0600 permissions in demo mode to keep sessions across restarts.
    • Prevent base config mutation in _deep_merge by deep-copying inputs.
    • Reject negative upload offsets with a 400 error to avoid session corruption.

- Add _load_or_generate_jwt_secret() function with priority order:
  1. FIXOPS_JWT_SECRET environment variable
  2. Persisted secret file (.jwt_secret)
  3. Generate and persist new secret (demo mode only)
- Secret file stored in FIXOPS_DATA_DIR/.jwt_secret with 0600 permissions
- Fixes bug where JWT secret was regenerated on every restart
- All user sessions now persist across application restarts
- Maintains backward compatibility with FIXOPS_JWT_SECRET env var

Resolves: Critical bug #1 - JWT secret not persisted
- Use copy.deepcopy() to create new result dictionary before merging
- Prevents base configuration from being mutated when reused across overlays
- Deep copy override values to ensure complete isolation
- Add comprehensive docstring explaining behavior
- Fixes configuration corruption bug when same base is merged multiple times

Resolves: Critical bug #2 - Configuration mutation bug
- Validate offset is non-negative before processing chunk upload
- Raise HTTPException 400 with descriptive error for negative offsets
- Prevents potential upload session corruption from invalid offset values
- Improves API robustness and error handling

Resolves: Medium severity issue #3 - Missing upload offset validation
- Create RateLimitMiddleware with configurable limits per IP address
- Track requests using sliding window algorithm with in-memory storage
- Add automatic cleanup of stale request trackers
- Support X-Forwarded-For header for proxied requests
- Return HTTP 429 with Retry-After header when limit exceeded
- Add rate limit headers to all responses (X-RateLimit-*)
- Configurable via environment variables:
  - FIXOPS_RATE_LIMIT_ENABLED (default: true)
  - FIXOPS_RATE_LIMIT_REQUESTS (default: 100)
  - FIXOPS_RATE_LIMIT_WINDOW_SECONDS (default: 60)
- Protects all API endpoints including authentication flows

Resolves: Medium severity issue #4 - No rate limiting on authentication
Comment thread apps/api/app.py
)
secret = secrets.token_hex(32)
try:
_JWT_SECRET_FILE.write_text(secret)

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.

Copilot Autofix

AI 7 months ago

The best way to fix this problem is to ensure that the JWT secret, if persisted to disk, is stored encrypted rather than as cleartext. The recommended approach is to encrypt the secret before writing it out, using a key not persisted with the secret (ideally, sourced from environment variables or a secure store like a vault). If that's not possible, the demo-mode secret should at least be encrypted using a local key derived at runtime (e.g., from a password or entropy unique to the local host).

Detailed steps for this fix:

  • Use the cryptography module's Fernet symmetric encryption for strong, simple encryption.
  • On writing the secret in demo mode: Generate a Fernet key (preferably from an environment variable; fallback to generating one at runtime and keeping it in memory for the session).
  • Encrypt the secret before writing to the file.
  • On reading: Decrypt the file contents before returning the secret.
  • Add needed imports for cryptography.fernet.

File/region to change:

  • apps/api/app.py, lines 61–116 (in _load_or_generate_jwt_secret).

Requirements:

  • Add Fernet key management (for the demo, can generate and hold in memory, with warning it's ephemeral).
  • Add import for cryptography.fernet.
  • Update file read/write to encrypt/decrypt secret.

Suggested changeset 2
apps/api/app.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/app.py b/apps/api/app.py
--- a/apps/api/app.py
+++ b/apps/api/app.py
@@ -8,6 +8,7 @@
 import secrets
 import shutil
 import uuid
+from cryptography.fernet import Fernet
 from contextlib import suppress
 from datetime import datetime, timedelta
 from pathlib import Path
@@ -58,14 +59,26 @@
 _JWT_SECRET_FILE = Path(os.getenv("FIXOPS_DATA_DIR", ".fixops_data")) / ".jwt_secret"
 
 
+def _get_demo_fernet_key() -> bytes:
+    """
+    For demo mode, generate or fetch a Fernet key (ephemeral, not persisted).
+    In production use, do not use this for encrypting secrets!
+    """
+    key = os.getenv("FIXOPS_DEMO_FERNET_KEY")
+    if not key:
+        key = Fernet.generate_key()
+        logger.warning("Using ephemeral Fernet key for demo JWT secret encryption. "
+                       "All tokens will be invalid after restart unless FIXOPS_DEMO_FERNET_KEY is set.")
+    return key.encode() if isinstance(key, str) else key
+
 def _load_or_generate_jwt_secret() -> str:
     """
     Load JWT secret from environment or file, or generate and persist a new one.
     
     Priority:
     1. FIXOPS_JWT_SECRET environment variable
-    2. Persisted secret file
-    3. Generate new secret and persist to file (demo mode only)
+    2. Persisted secret file (encrypted with Fernet in demo mode)
+    3. Generate new secret and persist to encrypted file (demo mode only)
     
     Returns:
         str: The JWT secret key
@@ -79,31 +86,46 @@
         logger.info("Using JWT secret from FIXOPS_JWT_SECRET environment variable")
         return env_secret
     
-    # Priority 2: Persisted file
+    # Priority 2: Persisted file (in demo mode, file is Fernet-encrypted)
+    mode = os.getenv("FIXOPS_MODE", "").lower()
     try:
         _JWT_SECRET_FILE.parent.mkdir(parents=True, exist_ok=True)
         if _JWT_SECRET_FILE.exists():
-            secret = _JWT_SECRET_FILE.read_text().strip()
-            if secret:
-                logger.info(f"Loaded persisted JWT secret from {_JWT_SECRET_FILE}")
-                return secret
+            if mode == "demo":
+                fkey = _get_demo_fernet_key()
+                fernet = Fernet(fkey)
+                encrypted_secret = _JWT_SECRET_FILE.read_bytes()
+                try:
+                    secret = fernet.decrypt(encrypted_secret).decode()
+                    if secret:
+                        logger.info(f"Loaded Fernet-encrypted JWT secret from {_JWT_SECRET_FILE}")
+                        return secret
+                except Exception as e:
+                    logger.warning(f"Failed to decrypt JWT secret file in demo mode: {e}")
+            else:
+                secret = _JWT_SECRET_FILE.read_text().strip()
+                if secret:
+                    logger.info(f"Loaded persisted JWT secret from {_JWT_SECRET_FILE}")
+                    return secret
     except Exception as e:
         logger.warning(f"Failed to read JWT secret file: {e}")
     
-    # Priority 3: Generate and persist (demo mode only)
-    mode = os.getenv("FIXOPS_MODE", "").lower()
+    # Priority 3: Generate and persist (demo mode only, Fernet-encrypted)
     if mode == "demo":
         secret = secrets.token_hex(32)
+        fkey = _get_demo_fernet_key()
+        fernet = Fernet(fkey)
+        encrypted_secret = fernet.encrypt(secret.encode())
         try:
-            _JWT_SECRET_FILE.write_text(secret)
+            _JWT_SECRET_FILE.write_bytes(encrypted_secret)
             _JWT_SECRET_FILE.chmod(0o600)  # Secure permissions
             logger.warning(
-                f"Generated and persisted new JWT secret to {_JWT_SECRET_FILE}. "
-                "For production, set FIXOPS_JWT_SECRET environment variable."
+                f"Generated and persisted new Fernet-encrypted JWT secret to {_JWT_SECRET_FILE}. "
+                "For production, set FIXOPS_JWT_SECRET environment variable and do NOT use demo mode."
             )
             return secret
         except Exception as e:
-            logger.error(f"Failed to persist JWT secret: {e}")
+            logger.error(f"Failed to persist encrypted JWT secret: {e}")
             logger.warning(
                 "Using non-persisted secret. Tokens will be invalid after restart."
             )
@@ -114,7 +122,6 @@
             "Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'"
         )
 
-
 JWT_SECRET = _load_or_generate_jwt_secret()
 
 
EOF
apps/api/requirements.txt
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt
--- a/apps/api/requirements.txt
+++ b/apps/api/requirements.txt
@@ -1,4 +1,5 @@
 fastapi>=0.110
+cryptography==46.0.3
 uvicorn[standard]>=0.30
 lib4sbom>=0.8.8
 sarif-om>=1.0.4
EOF
@@ -1,4 +1,5 @@
fastapi>=0.110
cryptography==46.0.3
uvicorn[standard]>=0.30
lib4sbom>=0.8.8
sarif-om>=1.0.4
This fix introduces these dependencies
Package Version Security advisories
cryptography (pypi) 46.0.3 None
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/configuration.py
Comment on lines 60 to +87
def _deep_merge(
base: MutableMapping[str, Any], overrides: Mapping[str, Any]
) -> MutableMapping[str, Any]:
"""
Deep merge two dictionaries, returning a new dictionary without mutating the base.

Args:
base: Base configuration dictionary (not modified)
overrides: Override values to merge in

Returns:
New dictionary with merged values
"""
import copy

# Create a deep copy to avoid mutating the base dictionary
result = copy.deepcopy(base)

for key, value in overrides.items():
if (
key in base
and isinstance(base[key], MutableMapping)
key in result
and isinstance(result[key], MutableMapping)
and isinstance(value, Mapping)
):
base[key] = _deep_merge(base[key], value) # type: ignore[assignment]
result[key] = _deep_merge(result[key], value) # type: ignore[assignment]
else:
base[key] = value # type: ignore[assignment]
return base
result[key] = copy.deepcopy(value) # type: ignore[assignment]
return result
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve mutation semantics in _deep_merge

Changing _deep_merge to return a deep-copied dictionary means the original base mapping is no longer mutated. Several existing callers still invoke _deep_merge(base, overrides) without assigning the return value (for example when applying profile overrides in core.configuration and in simulations), so their overrides silently stop taking effect. As a result configuration overlays and simulations will no longer merge correctly unless every call site is updated to capture the new result. Either keep mutating base or adjust all callers to use the returned object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 3 files

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="core/configuration.py">

<violation number="1" location="core/configuration.py:76">
Returning a deep-copied result here stops _deep_merge from mutating the provided base mapping; callers like load_overlay() still expect in-place merging, so profile overrides are no longer applied and overlay configuration breaks.</violation>
</file>

<file name="apps/api/rate_limiter.py">

<violation number="1" location="apps/api/rate_limiter.py:56">
The in-memory rate limiter is not effective in the project&#39;s default multi-process environment. The deployment configuration specifies multiple workers, but the rate-limiter&#39;s state is not shared between them, which undermines the feature&#39;s goal. A shared store like Redis, which is already in the stack, should be used.</violation>

<violation number="2" location="apps/api/rate_limiter.py:82">
The rate limiter is vulnerable to IP spoofing because it incorrectly parses the `X-Forwarded-For` header. It trusts the first IP, which can be client-controlled, instead of the last IP appended by the trusted Nginx proxy. This allows an attacker to bypass rate limits or cause denial-of-service for a different IP address.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

Comment thread core/configuration.py
import copy

# Create a deep copy to avoid mutating the base dictionary
result = copy.deepcopy(base)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning a deep-copied result here stops _deep_merge from mutating the provided base mapping; callers like load_overlay() still expect in-place merging, so profile overrides are no longer applied and overlay configuration breaks.

Prompt for AI agents
Address the following comment on core/configuration.py at line 76:

<comment>Returning a deep-copied result here stops _deep_merge from mutating the provided base mapping; callers like load_overlay() still expect in-place merging, so profile overrides are no longer applied and overlay configuration breaks.</comment>

<file context>
@@ -60,16 +60,31 @@ def _parse_overlay(text: str) -&gt; Dict[str, Any]:
+    import copy
+    
+    # Create a deep copy to avoid mutating the base dictionary
+    result = copy.deepcopy(base)
+    
     for key, value in overrides.items():
</file context>
Fix with Cubic

Comment thread apps/api/rate_limiter.py
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
# Take the first IP in the chain
return forwarded.split(",")[0].strip()
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate limiter is vulnerable to IP spoofing because it incorrectly parses the X-Forwarded-For header. It trusts the first IP, which can be client-controlled, instead of the last IP appended by the trusted Nginx proxy. This allows an attacker to bypass rate limits or cause denial-of-service for a different IP address.

Prompt for AI agents
Address the following comment on apps/api/rate_limiter.py at line 82:

<comment>The rate limiter is vulnerable to IP spoofing because it incorrectly parses the `X-Forwarded-For` header. It trusts the first IP, which can be client-controlled, instead of the last IP appended by the trusted Nginx proxy. This allows an attacker to bypass rate limits or cause denial-of-service for a different IP address.</comment>

<file context>
@@ -0,0 +1,199 @@
+        forwarded = request.headers.get(&quot;X-Forwarded-For&quot;)
+        if forwarded:
+            # Take the first IP in the chain
+            return forwarded.split(&quot;,&quot;)[0].strip()
+        
+        if request.client:
</file context>
Fix with Cubic

Comment thread apps/api/rate_limiter.py
self.request_count += 1


class RateLimitMiddleware(BaseHTTPMiddleware):
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-memory rate limiter is not effective in the project's default multi-process environment. The deployment configuration specifies multiple workers, but the rate-limiter's state is not shared between them, which undermines the feature's goal. A shared store like Redis, which is already in the stack, should be used.

Prompt for AI agents
Address the following comment on apps/api/rate_limiter.py at line 56:

<comment>The in-memory rate limiter is not effective in the project&#39;s default multi-process environment. The deployment configuration specifies multiple workers, but the rate-limiter&#39;s state is not shared between them, which undermines the feature&#39;s goal. A shared store like Redis, which is already in the stack, should be used.</comment>

<file context>
@@ -0,0 +1,199 @@
+        self.request_count += 1
+
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+    &quot;&quot;&quot;
+    Middleware to enforce rate limiting on API requests.
</file context>
Fix with Cubic

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Closing as part of PR consolidation. Useful changes have been cherry-picked into PR #240.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants